On this page

Skip to content

A Discussion on Best Practices for ASP.NET Core Web API File Uploads

This article documents my thoughts and experiences regarding file upload handling in Web API backend development; it does not cover specific implementation code. Due to the scope of this article, advanced techniques such as chunked uploads are not included.

Sample Project

An executable sample for this article: CloudyWing/SecureFileUploadSample.

This article focuses on design concepts rather than implementation code. The sample provides a complete implementation using .NET 10 + EF Core (SQLite in-memory mode), aligning with the design direction discussed here. The sample also includes features not mentioned in this article: file hash calculation (using the Content-Digest response header) and executable file detection.

Development Patterns and Data Reception

  • Full-stack development: Without specific AJAX handling, most scenarios involve submitting form data directly, with the backend using [FromForm] to receive the data.
  • Frontend-backend separation: Most scenarios involve passing JSON via AJAX, with the backend using [FromBody] to receive the data.

Two Main Processing Methods

Method 1: Using Form Submit or FormData

Pros:

  • Does not increase data transmission volume.

Cons:

  • In ASP.NET Core Web API, [FromBody] and [FromForm] use different ModelBinders when binding complex types. If you need to handle specific custom types, you must write a new ModelBinder for [FromForm] and a new JsonConverter for [FromBody].
  • Having two different processing methods in the system can confuse developers regarding which one to use, leading to inconsistent development standards.
  • Significant impact on the frontend; the transmission method must be changed entirely, especially when adding file upload functionality to an existing API.

Method 2: Using JSON to Pass Base64 Strings

Pros:

  • Convenient for both frontend and backend; no extra processing required.
  • High API format consistency.

Cons:

  • Data size increases by approximately 1.33 times, which may cause performance issues.

Conflict Case Between Frontend and Backend Engineers

While passing Base64 via JSON is more convenient for both sides, a backend engineer at my previous company refused this approach and switched to [FromForm], expecting the frontend to use FormData. This led to a dispute.

I cannot say that the engineer's approach was wrong; they might have been aiming for a more performant implementation. However, when a team already has established practices, one should communicate before implementing changes. I did not delve into their specific implementation at the time, so I am unsure if they intended to split file uploads into a separate API or change all file-related APIs to [FromForm]. If it was the latter, the dispute is more understandable.

For the backend, this change is minor—just adding the [FromForm] attribute to complex types. But for the frontend, it means the entire transmission method must be adjusted.

This is an approach shared by a colleague a few months ago, which I find to be relatively superior:

  1. Create a separate file upload API (using [FromForm]).
  2. Record file information in a file data table and return a file ID.
  3. Pass the file ID instead of the file content in the main business data creation/update API.

Pros:

  • Does not increase data transmission volume.
  • Simpler for the frontend to handle.
  • High API format consistency.

Additional Note: This approach also solves a problem I encountered in practice. Originally, when uploading files, the business data creation API lacked a file ID, but updates might require changing a specific file, forcing the frontend to pass an ID, which resulted in inconsistent API formats between creation and update. With this method, both only need to pass the file ID, maintaining consistency.

Implementation Details

Based on my colleague's sharing and my own experience, I have summarized the following implementation details:

  1. File Data Structure Design.

    • Create a dedicated file attachment table to record information such as filename, storage path, file size, download count, creation time, and enabled status.
    • The main business data table records the file ID, or a join table is created to handle one-to-many relationships.
  2. File Upload Workflow.

    • The frontend calls the file upload API first to send the file directly to the backend.
    • Upon receiving the file, the backend stores it on the file server and writes the file information into the file data table.
    • The backend returns the file ID to the frontend.
    • When the frontend creates/updates business data, it only needs to pass the file ID.
  3. Data Management Mechanism.

    • When business data is created, mark the file status as "Enabled".
    • Set up a scheduled task to periodically clean up files and their database records that have remained "Disabled" for more than a day to avoid excessive storage usage.
    • When deleting business data, you can choose to delete the associated files or keep them but mark them as "Disabled".
  4. File Data Validation.

    • Create a custom ValidationAttribute to verify the validity of the file data.
    • Since file information is stored in the database, you can use Dependency Injection (DI) to retrieve file information within the validation attribute:
    csharp
    protected override ValidationResult IsValid(
        object value, ValidationContext validationContext
    ) {
        using IServiceScope scope = validationContext.CreateScope();
        // Get the corresponding Service, Repository, or EfContext
        IFileService service = scope.ServiceProvider.GetRequiredService<IFileService>();
    }
    • Note the limitations of file type validation: it cannot detect file extension spoofing. Even with file signature identification, for most file types other than .exe, identification is either difficult or prone to false positives.
    • IValidatableObject's Validate method can also inject objects this way, but I personally dislike retrieving database data within Request Input. For logical validation involving database data, I prefer handling it in the Action or Service.

Extended Discussion: Considerations for File Download Implementation

After separating file uploads into an independent API, file download implementation also faces two design choices:

1. Generic File Download API (FileController):

  • Pros: Simple implementation, one API fits all scenarios, high code reusability.
  • Cons: Difficult permission control; cannot set fine-grained permission checks for different business scenarios.

2. Implementing Download API in Business Logic Controller (OrderController.DownloadInvoice()):

  • Pros: Can implement fine-grained permission control, high integration with business logic.
  • Cons: Each business module needs to write similar download logic, higher implementation complexity.

Personal Preference

I prefer integrating file download functionality into the business logic Controller. If the final requirement is purely downloading without any additional permission control logic, it might be considered over-engineering. Conversely, if a generic download method is used, the cost of modification will be higher when you later encounter files requiring permission control, or you will need to build additional processing mechanisms for specific files.

To reduce code duplication, you can create a base file download service class to encapsulate common logic, which can then be used by various business modules via dependency injection.

Change Log

  • 2025-03-10 Initial document creation.
  • 2026-05-21 Added link to the GitHub sample project.